﻿/// <reference path="../../../promises/Promises, Promises/Promises, Promises/Promises/Extensions.ts" />
/// <reference path="DocumentResource.ts" />
/// <reference path="DocumentResourceType.ts" />

module HeadTS {

    export class ResourceManager {
        /// <summary>
        /// Provides maangement for resources of a document.
        /// </summary>

        constructor(doc: HTMLDocument) {
            /// <summary>
            /// Initializes a new REsourceManager for a document.
            /// </summary>

            // Instantiate the new loader, exposing its loadAsync method on our load method.
            // We close over this instance to hide its existence.
            var managedDoc = new ManagedDocument(doc);
            this.loadAsync = (resource: DocumentResource) => managedDoc.loadAsync(resource);
        }

        loadAsync: (resource: DocumentResource) => Promises.IPromise;
    }

    class ManagedDocument {
        /// <summary>
        /// Represents a document whose resources are managed.
        /// </summary>

        /// <field name="isReady">A Promise that indicates the document has entered the "ready" state.</field>
        isReady: Promises.Promise;

        /// <field name="supportsAsynchronousScripts">Indicates whether the document supports native asynchronous script loading (i.e. "async scripts").</field>
        supportsAsynchronousScripts: boolean;

        /// <field name="resources">The resources that are managed by this document.</field>
        private resources: any;

        constructor(public doc: HTMLDocument) {
            /// <summary>
            /// Initializes a new ManagedDocument.
            /// </summary>

            // Perform the one-time tests on the document, applying the results to appropriate properties.
            this.supportsAsynchronousScripts = ManagedDocument.documentSupportsAsynchronousScripts(doc);
            this.isReady = ManagedDocument.documentIsReady(doc);

            // Initialize the resource collection, which we organize by name using the dictionary-like structure of the Object.
            this.resources = {};
        }

        loadAsync(resource: DocumentResource): Promises.IPromise {
            /// <summary>
            /// Loads a new resource into this ManagedDocument.
            /// </summary>

            // First, check whether a resource with the same name is already in the resource collection.
            // If it is, return the existing instance Promise; otherwise, create a new one.
            // Our policy is to ignore duplicates.
            var managedResource: ManagedDocumentResource = this.resources[resource.name],
                resourceExists = (managedResource !== undefined);

            // If the resource already exists and is defined, this is a re-definition, which we ignore.
            // Consequently, we return the Promise for the existing entry.
            if (resourceExists && managedResource.checkResourceIsPresent()) {

                return managedResource.isLoaded.promise();
            }
            else {
                // If the resource doesn't exist, create a ManagedDocumentResource to manage it and add it to the collection.
                // Otherwise, the resource exists, but it's a placeholder, so we need to apply this resource as the actual resource for management.
                if (!resourceExists) {
                    this.resources[resource.name] = managedResource = new ManagedDocumentResource(resource);
                }
                else {
                    managedResource.resource = resource;
                }

                // The resource is now defined (yay!), so we can resolve its dependencies and load it.
                // We return the Promise that represents the resource and its dependencies having been loaded.
                return this.loadDependenciesAsync(managedResource.resource.namedDependencies).then(() => this.loadResourceAsync(managedResource));
            }
        }

        whenLoaded(namedResource: String): Promises.IPromise {

            var resource: ManagedDocumentResource = this.resources[namedResource];
            if (resource === undefined) {
                this.resources[namedResource] = resource = new ManagedDocumentResource();
            }

            return resource.isLoaded.promise();
        }

        private loadDependenciesAsync(namedDependencies: String[]): Promises.IPromise {
            /// <summary>
            /// Loads a set of named dependencies asynchrously.
            /// </summary>
            /// <param name="namedDependencies">A set of named dependencies.</param>
            /// <returns>A Promise that is fulfilled when all the named dependencies have been loaded into the ManagedDocument or rejected if any dependency fails to load.</returns>

            // If there are no dependencies, return a fulfilled Promise to indicate that the dependencies are resolved.
            if (namedDependencies.length === 0) {
                return Promises.Promise.fulfilled;
            }

            // We have dependencies, so start resolving them.
            var dependencies: Promises.Promise[] = [];
            for (var i = 0; i < namedDependencies.length; i++) {
                var currentDependencyName = namedDependencies[i],
                    currentResource = this.resources[currentDependencyName];

                // If the dependency is not present, add an entry for it with no associated resource as a placeholder for a later definition.
                // This allows us to attach to a Promise now and fulfill it later. :-)
                if (currentResource === undefined) {
                    this.resources[currentDependencyName] = currentResource = new ManagedDocumentResource();
                }

                // Add the current dependency Promise to the list of dependencies so that we can wait until they're all resolved.
                dependencies.push(currentResource.isLoaded.promise());
            }

            // Return a Promise that represents all the dependencies being loaded.
            return Promises.Promise.whenAll(dependencies);
        }

        private loadResourceAsync(resource: ManagedDocumentResource): Promises.IPromise {
            /// <summary>
            /// Loads a managed resource asnychronously.
            /// </summary>
            /// <param name="resource">The managed resource to load.</param>
            /// <returns>A Promise that is either fulfilled when the managed resource is loaded or rejected when the managed resource fails to load.</returns>

            // Create a function to iterate over each source.
            var sourceIndex = 0;
            var loadNextResourceAsync = () => {

                // Load the resource into the document from the specified source.
                // If that works, run the test. If the test passes, fulfill the resource loading Promise.
                // If we can't load that resource or teh test fails, try the next one until one works, or we run out of sources to try.
                // If all sources fail to load, reject the resource loading Promise.
                return this.loadResourceFromSourceAsync(resource, sourceIndex)
                    .then(() => {

                        // The resource reported that it loaded, so we run the test.
                        // If the test filas or throws an exception, we transition to a rejected state by returning a rejected Promise.
                        // Otherwise, we continue onward by returning nothing.
                        try {
                            if (!resource.resource.test()) {
                                return Promises.Promise.rejected;
                            }
                        }
                        catch (testError) {
                            return Promises.Promise.rejected;
                        }
                    })
                    .then(() => resource.isLoaded.fulfill(), () => {

                        // We failed to load this resource, which is too bad.
                        // Remove the element that failed to load (for cleanliness).
                        resource.element.parentElement.removeChild(resource.element);
                        resource.element = null;

                        // Then try the next one (if one exists); if not, just reject the resource loading.
                        sourceIndex++;
                        if (sourceIndex < resource.resource.sources.length) {
                            loadNextResourceAsync();
                        }
                        else {
                            resource.isLoaded.reject();
                        }
                    });
            };

            // Start the loading process.
            loadNextResourceAsync();

            // Return the resource Promise, which represents the loading of the resource itself.
            return resource.isLoaded.promise();
        }

        private loadResourceFromSourceAsync(resource: ManagedDocumentResource, sourceIndex: number): Promises.IPromise {

            // Create a Deferred to represent the operation of attempting to load the resource.
            var result = new Promises.Deferred();

            // Create the element for this resource, assigning it to the managed resource.
            var el = resource.element = this.createElementForResource(resource, sourceIndex);

            // Add event handlers to monitor the element for loading completion / failure.
            // Note that we fallback to window events if the specific event object is not provided in order to support older browsers.
            el.onerror = (errorEvent) => result.reject(errorEvent || this.doc.parentWindow.event);
            el.onload = el.onreadystatechange = (loadEvent) => {

                loadEvent = event || this.doc.parentWindow.event;

                // This is right out of the HeadJs loadAsset method, which has a much more complete list of scenarios.
                // In effect, if this is the event we care about, fulfill the promise.
                if (loadEvent.type === "load" || (/loaded|complete/.test(el.readyState) && (!this.doc.documentMode || this.doc.documentMode < 9))) {
                    result.fulfill();
                }
            };

            // Ensure that we properly clear our event handlers.
            result.promise().always(() => {
                el.onerror = el.onload = el.onreadystatechange = null;
            });

            // Append the element to the end of the document head - this ensures that CSS is appended in the proper order for overrides.
            var head = this.doc.head || this.doc.getElementsByTagName("head")[0];
            head.insertBefore(el, head.lastChild);

            // Return the Promise that represents the operation.
            return result.promise();
        }

        private createElementForResource(resource: ManagedDocumentResource, sourceIndex: number): HTMLElement {

            if (resource.resource.type === DocumentResourceType.script) {

                // We're a script element, so we create, configure, and return it.
                var scriptElement = this.doc.createElement("script");
                scriptElement.type = "text/javascript";
                scriptElement.src = resource.resource.sources[sourceIndex];
                scriptElement.async = false;
                scriptElement.defer = false;
                return scriptElement;
            }
            else {

                // We're a style element, so we create, configure, and return it.
                var styleElement = this.doc.createElement("link");
                styleElement.type = "text/css";
                styleElement.rel = "stylesheet";
                styleElement.href = resource.resource.sources[sourceIndex];
                return styleElement;
            }

        }

        //#region Document Utilities

        private static documentSupportsAsynchronousScripts(doc: HTMLDocument): boolean {
            /// <summary>
            /// Determines whether an HTMLDocument supports asynchronous scripts.
            /// </summary>
            /// <param name="doc" type="HTMLDocument">An HTML document to examine.</param>
            /// <returns type="Boolean">Whether <paramref name="doc" /> supports asynchronous scripts.</returns>

            // We determine that an HTMLDocument supports asynchronous scripts if it passes one of these tests:
            //  1. The script elements support the "async" property. This is the best and clearest test.
            //  2. The browser is Mozilla-based.
            //  3. The browser is Opera.
            return (("async" in doc.createElement("script")) || ("MozAppearance" in doc.documentElement.style) || ("opera" in doc.parentWindow));
        }

        private static documentIsReady(doc: HTMLDocument): Promises.Promise {

            // Represent the operation with a Promise using a Deferred internally.
            var ready = new Promises.Deferred(),
                readyPromise = ready.promise();

            // A small utility wrapper to ensure the document body is present when we get our ready signal (IE can get overzealous - jQuery ticket #5443).
            var ensureReady = () => {

                // If the body isn't present yet, wait for a short time and check again.
                if (!doc.body) {
                    setTimeout(ensureReady, 50);
                }
                else {
                    ready.fulfill();
                }
            };

            // Attach to the document & window event handlers to ensure we don't miss anything.

            // Try to attach to events using the W3C-compliant methods supported by newer browsers.
            // Otherwise, we have to fallback to older mechanisms, a-la old IE.
            if (doc.addEventListener) {

                // Add some listeners to the document and window events.
                // We monitor both the document and the window (as a fallback).
                doc.addEventListener("DOMContentLoaded", ensureReady, false);
                doc.parentWindow.addEventListener("load", ensureReady, false);

                // Add some cleanup logic.
                readyPromise.always(() => {
                    doc.removeEventListener("DOMContentLoaded", ensureReady, false);
                    doc.parentWindow.removeEventListener("load", ensureReady, false);
                });
            }
            else {

                // We're old IE, so let's use the older event attachment methods.
                // We once more monitor both the document and window.
                doc.attachEvent("onreadystatechange", ensureReady);
                doc.parentWindow.attachEvent("onload", ensureReady);

                // Add some cleanup logic.
                readyPromise.always(() => {
                    doc.detachEvent("onreadystatechange", ensureReady);
                    doc.parentWindow.detachEvent("onload", ensureReady);
                });
            }

            // We've constructed our Promise and we do one final check in case the DOM completed while we were wiring things up.
            if (doc.readyState === "complete") {
                ensureReady();
            }

            // Return the Promise.
            return readyPromise;
        }

        //#endregion
    }

    class ManagedDocumentResource {
        /// <summary>
        /// A document resource whose loading is managed.
        /// This class supports the internal operations of the ManagedDocument class.
        /// </summary>

        /// <field name="documentElement">The HTML element that represents this resource. This field is managed by an external class.</field>
        element: HTMLElement = null;

        /// <field name="loaded">Represents the asynchronous loading of the resource. This is resolved or rejected by a resource manager external to this class.</field>
        isLoaded: Promises.Deferred = new Promises.Deferred();

        /// <field name="resource">The resource that is being managed. This field is assigned via the constructor, if it is provided.</field>

        constructor(public resource?: DocumentResource) {
        }

        checkResourceIsPresent(): Boolean {
            /// <summary>
            /// Determines whether this ManagedDocumentResource has been assigned a DocumentResource to manage.
            /// </summary>
            /// <returns>true if this instance has a DocumentResource to manage; otherwise, false.</returns>
            return ((this.resource !== undefined) && (this.resource !== null));
        }
    }
}